package com.atlassian.jira.plugin;

import EDU.oswego.cs.dl.util.concurrent.ReentrantWriterPreferenceReadWriteLock;
import EDU.oswego.cs.dl.util.concurrent.Sync;
import EDU.oswego.cs.dl.util.concurrent.WriterPreferenceReadWriteLock;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Level;
import org.apache.log4j.Logger;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

/**
 * 
 * @author David Chui &lt;david.chui@customware.net&gt;
 * 
 */
public class JarDependenciesLoader extends ClassLoader {

	private static final Logger LOGGER = Logger.getLogger(JarDependenciesLoader.class);

	private final URL jarUrl;

	private final Map resourceCache;

	private final Map classCache;

	private final ClassLoader parentClassLoader;

	private final WriterPreferenceReadWriteLock loadedClassesCacheLock;
	private final WriterPreferenceReadWriteLock loadedResourcesCacheLock;

	private static String localClassUrl = null;
	private static URL localJarUrl = null;

	static {
		try {
			localClassUrl = JarDependenciesLoader.class.getResource(JarDependenciesLoader.class.getSimpleName() + ".class").toExternalForm();
			LOGGER.info("Resolved class URL '" + localClassUrl);

			localJarUrl = new URL(localClassUrl.substring(4, localClassUrl.indexOf("!")));
			LOGGER.info("Resolved JAR URL '" + localJarUrl + "'");
		} catch (Exception e) {
			LOGGER.warn("Could not resolve local class and JAR URLs", e);
		}
	}

	public JarDependenciesLoader() {
		this(localJarUrl);
	}

	public JarDependenciesLoader(final URL jarUrl) {
		this(null, jarUrl);
	}

	public JarDependenciesLoader(final ClassLoader parentClassLoader, final URL jarUrl) {
		final ClassLoader threadContextClassLoader = Thread.currentThread().getContextClassLoader();
		if (null == jarUrl)
			throw new NullPointerException("URL to jar is null");

		this.parentClassLoader = null == parentClassLoader ? (null == threadContextClassLoader ? ClassLoader.getSystemClassLoader() : threadContextClassLoader)
				: parentClassLoader;

		this.jarUrl = jarUrl;
		resourceCache = new HashMap();
		classCache = new HashMap();

		loadedClassesCacheLock = new ReentrantWriterPreferenceReadWriteLock();
		loadedResourcesCacheLock = new ReentrantWriterPreferenceReadWriteLock();
	}

	public Class loadClass(String fqcn) throws ClassNotFoundException {

		Class loadedClass = null;

		try {
			loadedClass = getCachedClass(fqcn);

			if (null == loadedClass) {
				Sync writeLock = null;

				try {
					writeLock = loadedClassesCacheLock.writeLock();
					writeLock.acquire();

					if (null == (loadedClass = getCachedClass(fqcn))) {
						if (null == (loadedClass = findClass(fqcn)))
							/*
							 * Should throw ClassNotFoundException if class does
							 * not exist in parent ClassLoader
							 */
							loadedClass = parentClassLoader.loadClass(fqcn);

						/*
						 * At this point, we are sure that the Class has not
						 * been loaded before
						 */
						classCache.put(fqcn, loadedClass);
					}

				} finally {
					if (null != writeLock)
						writeLock.release();
				}
			}
		} catch (final InterruptedException ie) {
			throw new ClassNotFoundException("Unable to load class: " + fqcn, ie);
		}
		return loadedClass;
	}

	protected Class getCachedClass(final Object key) throws InterruptedException {
		Sync readLock = null;

		try {
			readLock = loadedClassesCacheLock.readLock();
			readLock.acquire();

			return (Class) classCache.get(key);

		} finally {
			if (null != readLock)
				readLock.release();
		}
	}

	protected Class findClass(final String name) throws ClassNotFoundException {
		Class loadedClass = null;
		ZipInputStream in = null;

		try {
			final byte[] classDefintion = getClassDefinition(name, in = new ZipInputStream(new BufferedInputStream(jarUrl.openStream())));

			if (classDefintion != null)
				loadedClass = defineClass(name, classDefintion, 0, classDefintion.length);
		} catch (final IOException ioe) {
			throw new ClassNotFoundException("Unable to load class: " + name, ioe);
		} finally {
			IOUtils.closeQuietly(in);
		}

		return loadedClass;
	}

	protected byte[] getClassDefinition(final String className, final ZipInputStream zipInputStream) throws IOException {
		byte[] classDefinition = null;
		ZipEntry zipEntry;

		while (null == classDefinition && null != (zipEntry = zipInputStream.getNextEntry())) {
			if (!zipEntry.isDirectory()) {
				final String[] zipNameComponentsLowerCase = StringUtils.split(zipEntry.getName().toLowerCase(), "/");

				if (zipNameComponentsLowerCase.length == 3 && zipNameComponentsLowerCase[0].equalsIgnoreCase("meta-inf")
						&& zipNameComponentsLowerCase[1].equalsIgnoreCase("lib")) {
					classDefinition = getClassDefinition(className, new ZipInputStream(new BufferedInputStream(zipInputStream)));
				} else {
					if (ArrayUtils.isEquals(StringUtils.split(className, "."), StringUtils.split(StringUtils.chomp(zipEntry.getName(), ".class"), "/"))) {
						ByteArrayOutputStream baos = new ByteArrayOutputStream();

						try {
							IOUtils.copy(zipInputStream, baos);
							classDefinition = baos.toByteArray();
							break;
						} finally {
							IOUtils.closeQuietly(baos);
						}
					}
				}
			}
		}

		return classDefinition;
	}

	protected URL findResource(final String path) {
		try {
			URL urlToResource = getCachedResource(path);

			if (null == urlToResource) {
				Sync writeLock = null;

				try {
					writeLock = loadedResourcesCacheLock.writeLock();
					writeLock.acquire();

					if (null == (urlToResource = getCachedResource(path))) {
						ZipInputStream in = null;

						try {
							in = new ZipInputStream(new BufferedInputStream(jarUrl.openStream()));

							if (null == (urlToResource = _findResource(path, in)))
								urlToResource = super.findResource(path);

						} finally {
							IOUtils.closeQuietly(in);
						}
					}

					if (null != urlToResource)
						resourceCache.put(path, urlToResource);

				} finally {
					if (null != writeLock)
						writeLock.release();
				}
			}

			return urlToResource;

		} catch (final URISyntaxException urise) {
			if (LOGGER.isEnabledFor(Level.ERROR))
				LOGGER.error("Unable to find resource " + path, urise);
		} catch (final InterruptedException ie) {
			if (LOGGER.isEnabledFor(Level.ERROR))
				LOGGER.error("Unable to find resource " + path, ie);
		} catch (final IOException ioe) {
			if (LOGGER.isEnabledFor(Level.WARN))
				LOGGER.warn("Unable to locate resource: " + path, ioe);

		}

		return null;
	}

	protected URL getCachedResource(final String path) throws InterruptedException, MalformedURLException, URISyntaxException {
		Sync readLock = null;

		try {
			URL resourceUrl;

			readLock = loadedResourcesCacheLock.readLock();
			readLock.acquire();

			resourceUrl = (URL) resourceCache.get(path);
			if (null != resourceUrl && resourceUrl.getProtocol().equals("file")) {
				final File resourceTempFile = new File(resourceUrl.toURI());

				resourceUrl = resourceTempFile.isFile() ? resourceTempFile.toURI().toURL() : null;
			}

			return resourceUrl;
		} finally {
			if (null != readLock)
				readLock.release();
		}

	}

	protected URL _findResource(final String path, final ZipInputStream zipInputStream) throws IOException {
		URL urlToResource = null;
		ZipEntry zipEntry;

		while (null != (zipEntry = zipInputStream.getNextEntry())) {
			if (!zipEntry.isDirectory()) {
				final String[] zipNameComponentsLowerCase = StringUtils.split(zipEntry.getName().toLowerCase(), "/");

				if (zipNameComponentsLowerCase.length == 3 && zipNameComponentsLowerCase[0].equalsIgnoreCase("meta-inf")
						&& zipNameComponentsLowerCase[1].equalsIgnoreCase("lib")) {
					urlToResource = _findResource(path, new ZipInputStream(new BufferedInputStream(zipInputStream)));
					break;
				} else {
					if (ArrayUtils.isEquals(StringUtils.split(path, "/"), StringUtils.split(zipEntry.getName(), "/"))) {
						urlToResource = createTemporaryResourceFile(zipInputStream).toURI().toURL();
						break;
					}
				}
			}
		}

		return urlToResource;
	}

	private File createTemporaryResourceFile(final InputStream resourceInput) throws IOException {
		final File resource = File.createTempFile(getClass().getName(), ".tmp");
		OutputStream out = null;

		resource.deleteOnExit();

		try {
			out = new BufferedOutputStream(new FileOutputStream(resource));
			IOUtils.copy(resourceInput, out);

			return resource;

		} finally {
			IOUtils.closeQuietly(out);
		}

	}
}
